# frozen_string_literal: true

require 'dotenv'
require 'open3'

fastlane_version '2.0'

default_platform :ios

# Paths that are re-used across multiple lanes
PROJECT_ROOT_FOLDER = File.dirname(File.expand_path(__dir__))
XCODE_PROJECT_PATH = File.join(PROJECT_ROOT_FOLDER, 'podcasts.xcodeproj')
FASTLANE_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'fastlane')
APP_STORE_METADATA_FOLDER = File.join(FASTLANE_FOLDER, 'metadata')
ARTIFACTS_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'artifacts')
SECRETS_FOLDER = File.join(Dir.home, '.configure', 'pocketcasts-ios', 'secrets')
VERSION_XCCONFIG_PATH = File.join(PROJECT_ROOT_FOLDER, 'config', 'Version.xcconfig')
RESOURCES_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'podcasts', 'Resources')
RELEASE_NOTES_SOURCE_PATH = File.join(PROJECT_ROOT_FOLDER, 'CHANGELOG.md')
EXTRACTED_RELEASE_NOTES_PATH = File.join(RESOURCES_FOLDER, 'release_notes.txt')
SENTRY_ORG_SLUG = 'a8c'
SENTRY_PROJECT_SLUG = 'pocket-casts-ios'

ENV_FILE_NAME = 'pocket-casts-ios.env'
USER_ENV_FILE_PATH = File.join(Dir.home, '.a8c-apps', ENV_FILE_NAME)

TEAM_ID_APP_STORE = 'PZYM8XX95Q'
TEAM_ID_ENTERPRISE = '99KV9Z6BKV'
EXTERNAL_BETA_TESTER_GROUPS = [
  'A8cs',
  'Alpha Slack',
  'Beta Slack',
  'Big Databases',
  'Reddit',
  'Stable Slack',
  'Trusted Preview Testers'
].freeze

APP_STORE_CONNECT_BUILD_OUTPUT_NAME = 'pocket-casts'
APP_STORE_CONNECT_XCARCHIVE_PATH = File.join(ARTIFACTS_FOLDER, "#{APP_STORE_CONNECT_BUILD_OUTPUT_NAME}.xcarchive")
APP_STORE_CONNECT_XCARCHIVE_ZIP_PATH = File.join(ARTIFACTS_FOLDER, "#{APP_STORE_CONNECT_BUILD_OUTPUT_NAME}.xcarchive.zip")
APP_STORE_CONNECT_BUILD_IPA_PATH = File.join(ARTIFACTS_FOLDER, "#{APP_STORE_CONNECT_BUILD_OUTPUT_NAME}.ipa")
APP_STORE_CONNECT_BUILD_DSYMS_PATH = File.join(ARTIFACTS_FOLDER, "#{APP_STORE_CONNECT_BUILD_OUTPUT_NAME}.app.dSYM.zip")

CODE_SIGNING_STORAGE_OPTIONS = {
  storage_mode: 's3',
  s3_bucket: 'a8c-fastlane-match',
  s3_region: 'us-east-2'
}.freeze

GITHUB_REPO = 'Automattic/pocket-casts-ios'
GITHUB_URL = "https://github.com/#{GITHUB_REPO}".freeze
DEFAULT_BRANCH = 'trunk'

SCHEME = 'pocketcasts'
SCHEME_ENTERPRISE = 'Pocket Casts Prototype Build'

PROTOTYPE_BUILD_NAME = 'pocket-casts-prototype'

# Instanstiate versioning classes
VERSION_CALCULATOR = Fastlane::Wpmreleasetoolkit::Versioning::SemanticVersionCalculator.new
VERSION_FORMATTER = Fastlane::Wpmreleasetoolkit::Versioning::FourPartVersionFormatter.new
BUILD_CODE_KEY = 'VERSION_LONG'
BUILD_CODE_FORMATTER = Fastlane::Wpmreleasetoolkit::Versioning::FourPartBuildCodeFormatter.new
VERSION_FILE = Fastlane::Wpmreleasetoolkit::Versioning::IOSVersionFile.new(xcconfig_path: VERSION_XCCONFIG_PATH)

APP_BUNDLE_IDENTIFIER_APP_STORE = 'au.com.shiftyjelly.podcasts'
APP_BUNDLE_IDENTIFIER_ENTERPRISE = "#{APP_BUNDLE_IDENTIFIER_APP_STORE}.prototype".freeze

# Keep this in alphabetical case-insensitive order so it's easier to visually compare with the list on the Developers Portal.
EXTENSIONS = %w[
  Clip
  NotificationContent
  NotificationExtension
  PodcastsIntents
  PodcastsIntentsUI
  Share-Extension
  watchkitapp
  WidgetExtension
].freeze

BUNDLE_IDENTIFIERS_APP_STORE = [
  APP_BUNDLE_IDENTIFIER_APP_STORE,
  *EXTENSIONS.map { |suffix| "#{APP_BUNDLE_IDENTIFIER_APP_STORE}.#{suffix}" }
].freeze

BUNDLE_IDENTIFIERS_ENTERPRISE = [
  APP_BUNDLE_IDENTIFIER_ENTERPRISE,
  *EXTENSIONS.map { |suffix| "#{APP_BUNDLE_IDENTIFIER_ENTERPRISE}.#{suffix}" }
].freeze

ASC_API_KEY_ENV_VARS = %w[
  APP_STORE_CONNECT_API_KEY_KEY_ID
  APP_STORE_CONNECT_API_KEY_ISSUER_ID
  APP_STORE_CONNECT_API_KEY_KEY
].freeze

FIREBASE_APP_ID = '1:124902176124:ios:3999f43bfa1fcd1b1620f9'
FIREBASE_TESTERS_GROUP = 'pocketcasts-ios---prototype-builds'

FROZEN_STRINGS_PATH = File.join(FASTLANE_FOLDER, 'Frozen.strings')

EN_LPROJ_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'podcasts', 'en.lproj')

# List of `.strings` files manually maintained by developers (as opposed to
# being automatically extracted from code and generated) which we will merge
# into the main `Localizable.strings` file imported by GlotPress, then extract
# back once we download the translations.
#
# Each `.strings` file to be merged/extracted is associated with a prefix to
# add to the keys, used to avoid conflicts and differentiate the source of the
# copies.
#
# See calls to `ios_merge_strings_files` and
# `ios_extract_keys_from_strings_files` for usage.
MANUALLY_MAINTAINED_STRINGS_FILES = {
  File.join(EN_LPROJ_FOLDER, 'Localizable.strings') => nil,
  File.join(EN_LPROJ_FOLDER, 'InfoPlist.strings') => 'infoplist_',
  File.join(EN_LPROJ_FOLDER, 'Intents.strings') => 'siri_intent_definition_key_'
}.freeze

# List of exceptions / known words during spell-checking. Used by the `spellcheck_strings` lane.
SPELLCHECK_KNOWN_WORDS = %w[
  Pocketcasts Automattic
  OPML opml URL url RSS rss
  Preselect preselect
  Unplayed unplayed
  Autoplay autoplay
  Unarchive unarchive Unarchived unarchived Unarchiving unarchiving
  Unstar unstar Unstarring unstarring
  EP
  Alexa Siri Sonos Castbox castbox
  uuid VPN macOS
  outro Rosé
  Bazinga Wahhh Aaaah Heyooo Ew
].freeze

# URL of the GlotPress project containing the strings used in the app
GLOTPRESS_APP_STRINGS_PROJECT_URL = 'https://translate.wordpress.com/projects/pocket-casts/ios/'
# URL of the GlotPress project containing App Store Connect metadata
GLOTPRESS_APP_STORE_METADATA_PROJECT_URL = 'https://translate.wordpress.com/projects/pocket-casts/ios/release-notes/'

# List of locales used for the app strings (GlotPress code => `*.lproj` folder name`).
# Sorted like Xcode sorts them in the File Inspector for easier comparison.
#
# TODO: Replace with `LocaleHelper` once provided by release toolkit (https://github.com/wordpress-mobile/release-toolkit/pull/296)
GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES = {
  'zh-cn' => 'zh-Hans', # Chinese (China, Simplified)
  'zh-tw' => 'zh-Hant', # Chinese (Taiwan, Traditional)
  'nl' => 'nl',         # Dutch
  'fr' => 'fr',         # French
  'fr-ca' => 'fr-CA',   # French (Canadian)
  'de' => 'de',         # German
  'it' => 'it',         # Italian
  'ja' => 'ja',         # Japanese
  'pt-br' => 'pt-BR',   # Portuguese (Brazil)
  'ru' => 'ru',         # Russian
  'es' => 'es',         # Spanish
  'es-mx' => 'es-MX',   # Spanish (Mexico)
  'sv' => 'sv',         # Swedish
  'ca' => 'ca'          # Catalan
}.freeze

# Mapping of all locales which can be used for AppStore metadata
# (GlotPress code => AppStore Connect code)
#
# TODO: Replace with `LocaleHelper` once provided by release toolkit
# (https://github.com/wordpress-mobile/release-toolkit/pull/296)
GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES = {
  'de' => 'de-DE',
  'es' => 'es-ES',
  'fr' => 'fr-FR',
  'it' => 'it',
  'ja' => 'ja',
  'nl' => 'nl-NL',
  'pt-br' => 'pt-BR',
  'ru' => 'ru',
  'sv' => 'sv',
  'zh-cn' => 'zh-Hans',
  'zh-tw' => 'zh-Hant'
}.freeze

import 'lib/helpers.rb'

before_all do
  # This is necessary for 'match' to work correctly in CI. When running
  # locally, it has no effect so it's safe to run it before all lanes.
  setup_ci

  # Decrypt the secrets. This is redundant on dev machines most of the time,
  # but it has such a negligible overhead that it's worth running it here to
  # keep the individual lanes cleaner.
  configure_apply

  Dotenv.load(USER_ENV_FILE_PATH)
end

platform :ios do
  # This explicit timeout is necessary for the `xcodebuild -showBuildSettings` call to succeed in CI.
  # See https://buildkite.com/automattic/pocket-casts-ios/builds/4875#018b7eea-e8d7-40a1-8ce5-515dd8feafce/666-805
  ENV['FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT'] = '120'

  # Run the unit tests
  #
  lane :test do
    run_tests(
      scheme: SCHEME
    )
  end

  # Download and configure code signing certificates and provisioning profiles for the App Store build.
  #
  # @param readonly [Boolean] Use `true` when you only want to **fetch** existing profiles and certificates from out S3 bucket via `match`
  #                           Use `false` only if you need `match` to **regenerate** profiles and/or certificates in the Dev Portal and upload the updated ones to S3
  #
  lane :configure_code_signing_app_store do |readonly: true|
    require_env_vars!(*ASC_API_KEY_ENV_VARS)

    sync_code_signing(
      type: 'appstore',
      team_id: TEAM_ID_APP_STORE,
      api_key: app_store_connect_api_key,
      app_identifier: BUNDLE_IDENTIFIERS_APP_STORE,
      # This might turn out to be useful in the future
      # template_name: 'CarPlay audio app (CarPlay + Media Player frameworks)iOS (Dist)'
      readonly: readonly,
      **CODE_SIGNING_STORAGE_OPTIONS
    )
  end

  # Download and configure code signing certificates and provisioning profiles for the Enterprise build, i.e. Prototype Builds.
  #
  # @param readonly [Boolean] Use `true` when you only want to **fetch** existing profiles and certificates from out S3 bucket via `match`
  #                           Use `false` only if you need `match` to **regenerate** profiles and/or certificates in the Dev Portal and upload the updated ones to S3
  #
  lane :configure_code_signing_enterprise do |readonly: true|
    require_env_vars!(*ASC_API_KEY_ENV_VARS)

    if readonly
      # In readonly mode, we can use the API key.
      #
      # Notice the `in_house: true` parameter.
      # Unfortunately, even using it, the API does not allow modifying certs or profiles via API key.
      # It's here for reference and future-proofing.
      api_key = app_store_connect_api_key(in_house: true)
    else
      # The Enterprise account APIs do not support authentication via API key.
      # If we want to modify data (readonly = false) we need to authenticate manually.
      prompt_user_for_app_store_connect_credentials
      # We also need to pass no API key, otherwise Fastlane will give precedence to that authentication mode.
      api_key = nil
    end

    sync_code_signing(
      type: 'enterprise',
      team_id: TEAM_ID_ENTERPRISE,
      api_key: api_key,
      app_identifier: BUNDLE_IDENTIFIERS_ENTERPRISE,
      # This might turn out to be useful in the future
      # template_name: 'CarPlay audio app (CarPlay + Media Player frameworks)iOS (Dist)'
      readonly: readonly,
      **CODE_SIGNING_STORAGE_OPTIONS
    )
  end

  #####################################################################################
  # Release Lanes
  #####################################################################################

  # Executes the code freeze steps
  #
  # - Cuts a new release branch, Bumps the version
  # - Extracts the Release Notes
  # - Generates `.strings` files from code then merges the other, manually-maintained `.strings` files with it
  # - Triggers the build of the first beta on CI
  # - Open backmerge PR
  # - Enables the GitHub branch protection for the new branch
  # - Move all still-opened PRs targeting current milestone to the next one and Freezes the GitHub milestone
  #
  # @param [String] version (optional) The version to use for the new release version to code freeze for.
  #                 Typically auto-provided by ReleasesV2. If nil, computes the new version based on current one.
  # @param [Boolean] skip_confirm If true, avoids any interactive prompt (default: false)
  #
  lane :code_freeze do |version: nil, skip_confirm: false|
    require_env_vars!('GITHUB_TOKEN')
    # Verify that there's nothing in progress in the working copy
    ensure_git_status_clean

    # Check out the up-to-date default branch, the designated starting point for the code freeze
    Fastlane::Helper::GitHelper.checkout_and_pull(DEFAULT_BRANCH)

    # If a new version is passed, use it as source of truth from now on
    new_version = version || release_version_next
    release_branch_name = "release/#{new_version}"
    new_build_code = build_code_code_freeze(version_short: new_version)

    message = <<-MESSAGE

      Code Freeze:
      • New release/ branch from #{DEFAULT_BRANCH} will be: #{release_branch_name}

      • Current release version and build code: #{release_version_current} (#{build_code_current}).
      • New release version and build code: #{new_version} (#{new_build_code}).

    MESSAGE
    UI.important(message)
    UI.user_error!('Aborted by user request') unless skip_confirm || UI.confirm('Do you want to continue?')

    # Create the release branch
    ensure_branch_does_not_exist!(release_branch_name)

    UI.message 'Creating release branch...'
    Fastlane::Helper::GitHelper.create_branch(release_branch_name, from: DEFAULT_BRANCH)
    UI.success "Done! New release branch is: #{git_branch}"

    # Bump the release version and build code and write it to the `xcconfig` file
    UI.message 'Bumping release version and build code...'
    VERSION_FILE.write(
      version_short: new_version,
      version_long: new_build_code
    )
    commit_version_bump
    UI.success "Done! New Release Version: #{release_version_current}. New Build Code: #{build_code_current}."

    UI.message 'Extracting release notes...'
    extract_release_notes_for_version(
      version: release_version_current,
      release_notes_file_path: RELEASE_NOTES_SOURCE_PATH,
      extracted_notes_file_path: EXTRACTED_RELEASE_NOTES_PATH
    )
    ios_update_release_notes(
      new_version: release_version_current,
      release_notes_file_path: RELEASE_NOTES_SOURCE_PATH
    )

    generate_strings_file_for_glotpress

    push_to_git_remote(set_upstream: true, tags: false)

    trigger_beta_build(branch_to_build: release_branch_name)
    create_backmerge_pr

    # Copy the branch protection settings from the default branch to the new release branch
    copy_branch_protection(repository: GITHUB_REPO, from_branch: DEFAULT_BRANCH, to_branch: release_branch_name)
    # But allow admins to bypass restrictions, so that wpmobilebot can push to the release branch directly for beta version bumps
    set_branch_protection(repository: GITHUB_REPO, branch: release_branch_name, enforce_admins: false)

    # Move still-open PRs to next milestone, then add frozen marker to milestone title
    update_milestone
  end

  # Creates a new beta by bumping the app version appropriately then triggering a beta build on CI
  #
  # @param base_version [String] If set, bases the beta on the specified version and `release/<base_version>` branch
  #                              instead of using the current app version. Useful e.g. for triggering betas on hotfixes.
  # @param skip_confirm [Boolean] If true, avoids any interactive prompt (default: false)
  #
  lane :new_beta_release do |base_version: nil, skip_confirm: false|
    # Verify that there's nothing in progress in the working copy
    ensure_git_status_clean

    # Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
    ensure_git_branch(branch: '^release/')

    # Check versions
    message = <<-MESSAGE

      Current build code: #{build_code_current}
      New build code: #{build_code_next}

    MESSAGE

    # Check branch
    unless !base_version.nil? || Fastlane::Helper::GitHelper.checkout_and_pull(release: release_version_current)
      UI.user_error!("#{message}Release branch for version #{release_version_current} doesn't exist. Abort.")
    end

    # Check user override
    override_default_release_branch(base_version) unless base_version.nil?

    UI.important(message)
    UI.user_error!('Aborted by user request') unless skip_confirm || UI.confirm('Do you want to continue?')

    # Re-generate the strings for GlotPress, just in case there were localization fixes.
    generate_strings_file_for_glotpress

    download_localized_strings_and_metadata_from_glotpress
    lint_localizations

    # Bump the build code
    UI.message 'Bumping build code...'
    # Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
    ensure_git_branch(branch: '^release/')
    VERSION_FILE.write(version_long: build_code_next)
    commit_version_bump
    UI.success "Done! New Build Code: #{build_code_current}"

    push_to_git_remote(tags: false)

    trigger_beta_build(branch_to_build: release_branch_name)
    create_backmerge_pr
  end

  # Finalizes a release at the end of a sprint to submit to the App Store
  #
  #  - Updates store metadata
  #  - Bumps final version number
  #  - Removes branch protection and close milestone
  #  - Triggers the final release on CI
  #
  # @param skip_confirm [Boolean] If true, avoids any interactive prompt (default: false)
  #
  lane :finalize_release do |skip_confirm: false|
    UI.user_error!('To finalize a hotfix, please use the finalize_hotfix_release lane instead') if current_version_hotfix?

    require_env_vars!('GITHUB_TOKEN')

    # Verify that there's nothing in progress in the working copy
    ensure_git_status_clean

    # Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
    ensure_git_branch(branch: '^release/')

    UI.important("Finalizing release: #{release_version_current}")
    UI.user_error!('Aborted by user request') unless skip_confirm || UI.confirm('Do you want to continue?')

    check_all_translations_progress(interactive: !skip_confirm)

    download_localized_strings_and_metadata_from_glotpress
    lint_localizations

    # Bump the build code
    UI.message 'Bumping build code...'
    VERSION_FILE.write(version_long: build_code_next)
    commit_version_bump
    push_to_git_remote(tags: false)
    UI.success "Done! New Build Code: #{build_code_current}"

    # Wrap up
    set_milestone_frozen_marker(repository: GITHUB_REPO, milestone: release_version_current, freeze: false)
    close_milestone(repository: GITHUB_REPO, milestone: release_version_current)

    # Start the build

    trigger_release_build(branch_to_build: release_branch_name)
    create_backmerge_pr
  end

  # This lane publishes a release on GitHub and creates a PR to backmerge the current release branch into the next release/ branch
  #
  # @param [Boolean] skip_confirm (default: false) If set, will skip the confirmation prompt before running the rest of the lane
  #
  lane :publish_release do |skip_confirm: false|
    ensure_git_status_clean
    ensure_git_branch(branch: '^release/')

    version_number = release_version_current

    current_branch = "release/#{version_number}"
    next_release_branch = "release/#{release_version_next}"

    UI.important <<~PROMPT
      Publish the #{version_number} release. This will:
      - Publish the existing draft `#{version_number}` release on GitHub
      - Which will also have GitHub create the associated git tag, pointing to the tip of the branch
      - If the release branch for the next version `#{next_release_branch}` already exists, backmerge `#{current_branch}` into it
      - If needed, backmerge `#{current_branch}` back into `#{DEFAULT_BRANCH}`
      - Delete the `#{current_branch}` branch
    PROMPT
    UI.user_error!("Terminating as requested. Don't forget to run the remainder of this automation manually.") unless skip_confirm || UI.confirm('Do you want to continue?')

    UI.important "Publishing release #{version_number} on GitHub"

    publish_github_release(
      repository: GITHUB_REPO,
      name: version_number
    )

    create_backmerge_pr

    # At this point, an intermediate branch has been created by creating a backmerge PR to a hotfix or the next version release branch.
    # This allows us to safely delete the `release/*` remote branch.
    remove_branch_protection(repository: GITHUB_REPO, branch: current_branch)
    Fastlane::Helper::GitHelper.delete_remote_branch_if_exists!(current_branch)
    Fastlane::Helper::GitHelper.checkout_and_pull(DEFAULT_BRANCH)
    Fastlane::Helper::GitHelper.delete_local_branch_if_exists!(current_branch)
  end

  # Sets the stage to start working on a hotfix
  #
  # - Cuts a new `release/x.y.z` branch from the tag from the latest (`x.y`) version
  # - Bumps the app version numbers appropriately
  #
  # @param version [String] (required) The version number to use for the hotfix (`"x.y.z"`)
  # @param skip_confirm [Boolean] If true, avoids any interactive prompt (default: false)
  #
  lane :new_hotfix_release do |version:, skip_confirm: false|
    # Verify that there's nothing in progress in the working copy
    ensure_git_status_clean

    build_code_hotfix = build_code_hotfix(release_version: version)

    # Parse the provided version into an AppVersion object
    parsed_version = VERSION_FORMATTER.parse(version)
    # Validate that this is a hotfix version (must have a patch component > 0)
    UI.user_error!("Invalid hotfix version '#{version}'. Must include a patch number.") unless parsed_version.patch.to_i.positive?
    previous_version = VERSION_FORMATTER.release_version(VERSION_CALCULATOR.previous_patch_version(version: parsed_version))

    release_branch_name = "release/#{version}"
    previous_release_branch = "release/#{previous_version}"

    # Determine the base for the hotfix branch: either a tag or a release branch
    base_ref_for_hotfix = if git_tag_exists(tag: previous_version, remote: true)
                            previous_version
                          elsif Fastlane::Helper::GitHelper.branch_exists_on_remote?(branch_name: previous_release_branch)
                            UI.message("ℹ️  Tag '#{previous_version}' not found on the remote. Using release branch '#{previous_release_branch}' as the base for hotfix instead.")
                            previous_release_branch
                          else
                            UI.user_error!("Neither tag '#{previous_version}' nor branch '#{previous_release_branch}' exists on the remote! A hotfix branch cannot be created.")
                          end

    # Check versions
    message = <<-MESSAGE

      New hotfix branch from #{base_ref_for_hotfix}: #{release_branch_name}

      Current release version: #{release_version_current}
      New hotfix version: #{version}

      Current build code: #{build_code_current}
      New build code: #{build_code_hotfix}

    MESSAGE

    UI.important(message)
    UI.user_error!('Aborted by user request') unless skip_confirm || UI.confirm('Do you want to continue?')

    # Check tags
    UI.user_error!("Version '#{version}' already exists on the remote! Abort!") if git_tag_exists(tag: version, remote: true)

    # Fetch the base ref to ensure it's available locally
    sh('git', 'fetch', 'origin', base_ref_for_hotfix)

    ensure_branch_does_not_exist!(release_branch_name)

    # Create the hotfix branch
    UI.message("Creating hotfix branch from '#{base_ref_for_hotfix}'...")
    Fastlane::Helper::GitHelper.create_branch(release_branch_name, from: base_ref_for_hotfix)
    UI.success("Done! New hotfix branch is: '#{git_branch}'")

    # Bump the hotfix version and build code and write it to the `xcconfig` file
    UI.message('Bumping hotfix version and build code...')
    VERSION_FILE.write(
      version_short: version,
      version_long: build_code_hotfix
    )
    UI.success("Done! New Release Version: '#{release_version_current}'. New Build Code: '#{build_code_current}'")

    commit_version_bump
    push_to_git_remote(set_upstream: true, tags: false)
  end

  # Finalizes a hotfix, by triggering a release build on CI
  #
  lane :finalize_hotfix_release do |skip_confirm: false|
    # Verify that the current branch is a release branch. Notice that `ensure_git_branch` expects a RegEx parameter
    ensure_git_branch(branch: '^release/')

    # Verify that there's nothing in progress in the working copy
    ensure_git_status_clean

    UI.important("Triggering hotfix build for version: #{release_version_current}")
    UI.user_error!('Aborted by user request') unless skip_confirm || UI.confirm('Do you want to continue?')
    push_to_git_remote(tags: false)
    trigger_release_build(branch_to_build: "release/#{release_version_current}")

    create_backmerge_pr
    begin
      close_milestone(repository: GITHUB_REPO, milestone: release_version_current)
    rescue StandardError => e
      UI.important("Could not find a GitHub milestone for hotfix #{release_version_current} to close: #{e}")
    end
  end

  # Triggers a beta build on CI
  #
  # @param branch_to_build [String] The name of the branch we want the CI to build, e.g. `release/19.3`
  #
  lane :trigger_beta_build do |branch_to_build:|
    trigger_buildkite_release_build(branch: branch_to_build, beta: true)
  end

  # Triggers a stable release build on CI
  #
  # @param branch_to_build [String] The name of the branch we want the CI to build, e.g. `release/19.3`
  #
  lane :trigger_release_build do |branch_to_build:|
    trigger_buildkite_release_build(branch: branch_to_build, beta: false)
  end

  # Builds the app binary for distribution to App Store Connect
  #
  # Once the app binary is built by this lane, you might want to call:
  #  - `upload_app_store_connect_build_to_testflight` to upload the binary to TestFlight
  #  - `symbols_upload` to upload dSYMs to Sentry
  #  - `create_release_on_github(beta_release: …)` to create the GitHub Release
  #
  # @param set_up_code_signing [Boolean] If true, will call `configure_code_signing_app_store` before building. Recommended on CI.
  #
  lane :build_app_store_connect do |set_up_code_signing: true|
    configure_code_signing_app_store if set_up_code_signing

    build_app(
      scheme: SCHEME,
      include_bitcode: false,
      include_symbols: true,
      clean: true,
      export_options: {
        method: 'app-store',
        manageAppVersionAndBuildNumber: false
      },
      # The options below might seem redundant but are currently all necessary to have predictable artifact paths to use in other lanes.
      #
      # - archive_path sets the full path for the xcarchive.
      # - output_directory and output_name set the path and basename for the ipa and dSYM.
      #
      # We could have used 'build_path: OUTPUT_DIRECTORY_PATH' for the xcarchive...
      # ...but doing so would append a timestamp and unnecessarily complicate other logic to get the path
      archive_path: APP_STORE_CONNECT_XCARCHIVE_PATH,
      output_directory: ARTIFACTS_FOLDER,
      output_name: APP_STORE_CONNECT_BUILD_OUTPUT_NAME
    )

    # It's convenient to have a ZIP available here for later steps.
    UI.message("Zipping #{APP_STORE_CONNECT_XCARCHIVE_PATH} to #{APP_STORE_CONNECT_XCARCHIVE_ZIP_PATH}...")
    zip(path: APP_STORE_CONNECT_XCARCHIVE_PATH, output_path: APP_STORE_CONNECT_XCARCHIVE_ZIP_PATH)
  end

  # Uploads the binary to TestFlight and distributes it to testers
  #
  # Typically called after having built the binary with `build_app_store_connect`
  #
  # @param ipa_path [String] Path to the `.ipa` file to upload to TestFlight
  #
  lane :upload_app_store_connect_build_to_testflight do |ipa_path: APP_STORE_CONNECT_BUILD_IPA_PATH|
    require_env_vars!(*ASC_API_KEY_ENV_VARS)

    changelog = extract_release_notes_for_version(
      version: release_version_current,
      release_notes_file_path: RELEASE_NOTES_SOURCE_PATH
    )
    changelog = +'Minor changes.' if changelog.empty?
    changelog.gsub!(/ ?\[#[0-9]+\]\(https:.*\)/, '') # Remove GitHub links

    upload_to_testflight(
      ipa: ipa_path,
      reject_build_waiting_for_review: true,
      distribute_external: true,
      groups: EXTERNAL_BETA_TESTER_GROUPS,
      notify_external_testers: true,
      changelog: changelog,
      team_id: TEAM_ID_APP_STORE,
      api_key: app_store_connect_api_key
    )
  end

  # Builds the app binary for Enterprise distribution (Prototype Build)
  #
  # Once the app binary is built, you might want to call `upload_enterprise`
  #
  # @param fetch_code_signing [Boolean] If true, will call `configure_code_signing_app_store` before building. Recommended on CI.
  #
  lane :build_enterprise do |set_up_code_signing: true|
    if set_up_code_signing
      configure_code_signing_enterprise
    else
      UI.message('Skipping fetch certificates and provisioning profiles as requested.')
    end

    UI.important('Hacking the project file to remove the App Clip dependency because Enterprise distribution does not support it...')
    remove_app_clip_dependency!
    UI.important('Remember not to commit the modified project file! The change is only meant to allow building for Enterprise where App Clips are not supported.')

    build_number = ENV.fetch('BUILDKITE_BUILD_NUMBER', '0')
    pr_or_branch = pull_request_number&.then { |num| "PR ##{num}" } || ENV.fetch('BUILDKITE_BRANCH', nil)

    build_app(
      scheme: SCHEME_ENTERPRISE,
      include_bitcode: false,
      include_symbols: true,
      clean: true,
      xcargs: { VERSION_SHORT: pr_or_branch, VERSION_LONG: build_number },
      output_directory: ARTIFACTS_FOLDER,
      output_name: PROTOTYPE_BUILD_NAME,
      export_options: {
        method: 'enterprise',
        manageAppVersionAndBuildNumber: false
      }
    )
  end

  # Distributes the Prototype Build to Firebase App Distribution
  #
  # Typically called after having built the binary with `build_enterprise`
  #
  # @note Expects the `.ipa` and `.dSYM` files to be present in `ARTIFACTS_FOLDER/PROTOTYPE_BUILD_NAME.{ipa,app.dSYM.zip}`
  #
  lane :upload_enterprise do
    require_env_vars!('FIREBASE_APP_DISTRIBUTION_ACCOUNT_KEY')

    release_notes = <<~NOTES
      Pull Request: ##{pull_request_number || 'N/A'}
      Branch: `#{ENV.fetch('BUILDKITE_BRANCH', 'N/A')}`
      Commit: #{ENV.fetch('BUILDKITE_COMMIT', 'N/A')[0...7]}
    NOTES

    firebase_app_distribution(
      app: FIREBASE_APP_ID,
      ipa_path: File.join(ARTIFACTS_FOLDER, "#{PROTOTYPE_BUILD_NAME}.ipa"),
      service_credentials_json_data: get_required_env!('FIREBASE_APP_DISTRIBUTION_ACCOUNT_KEY'),
      release_notes: release_notes,
      groups: FIREBASE_TESTERS_GROUP
    )

    next if pull_request_number.nil?

    # Post PR Comment
    comment_body = prototype_build_details_comment(
      app_display_name: 'Pocket Casts Prototype Build',
      fold: true
    )
    comment_on_pr(
      project: GITHUB_REPO,
      pr_number: pull_request_number,
      reuse_identifier: 'prototype-build-link',
      body: comment_body
    )
  end

  # Uploads DSYM Symbols
  #
  lane :symbols_upload do |dsym_path: APP_STORE_CONNECT_BUILD_DSYMS_PATH|
    require_env_vars!('SENTRY_AUTH_TOKEN')

    symbols_path = dsym_path || lane_context[SharedValues::DSYM_OUTPUT_PATH]
    sentry_debug_files_upload(
      auth_token: get_required_env!('SENTRY_AUTH_TOKEN'),
      org_slug: SENTRY_ORG_SLUG,
      project_slug: SENTRY_PROJECT_SLUG,
      path: symbols_path
    )
  end

  # Create a GitHub Release for a beta or final build
  #
  # @param beta_release [Boolean] If true, the GitHub Release is for a beta and will be marked as prerelease and published immediately.
  #                               If false, the GitHub Release is for a final build and will not be marked as prerelease but will be created as Draft (to be manually published only once approved by Apple)
  # @param archive_zip_path [String] Path to the `.xcarchive.zip` file to attach as an asset to the created GitHub Release
  #
  lane :create_release_on_github do |beta_release: true, archive_zip_path: APP_STORE_CONNECT_XCARCHIVE_ZIP_PATH|
    require_env_vars!('GITHUB_TOKEN', 'SLACK_WEBHOOK')

    app_version = release_version_current
    build_version = build_code_current
    version = beta_release ? build_version : app_version

    changelog = extract_release_notes_for_version(
      version: release_version_current,
      release_notes_file_path: RELEASE_NOTES_SOURCE_PATH
    )
    changelog = '_`CHANGELOG.md` was empty for this version._' if changelog.empty?

    UI.message("Creating #{version} release on GitHub...")
    set_github_release(
      repository_name: GITHUB_REPO,
      api_token: get_required_env!('GITHUB_TOKEN'),
      name: version,
      tag_name: version,
      description: changelog,
      commitish: Git.open(PROJECT_ROOT_FOLDER).log.first.sha,
      upload_assets: [archive_zip_path.to_s],
      is_draft: !beta_release,
      is_prerelease: beta_release
    )

    UI.message('Sending message to Slack...')
    slack(
      pretext: slack_message(version: app_version, build_number: build_version, is_beta: beta_release),
      default_payloads: [],
      slack_url: get_required_env!('SLACK_WEBHOOK'),
      fail_on_error: false
    )
  end

  #####################################################################################
  # Localization Lanes
  #####################################################################################

  # Generates the `.strings` file to be imported by GlotPress, by parsing source
  # code.
  #
  # @param skip_commit [Boolean] If true, does not commit the changes made to the `.strings` file (default: false)
  #
  # @note Uses `genstrings` under the hood.
  #
  lane :generate_strings_file_for_glotpress do |skip_commit: false|
    # Delete the previous frozen `.strings` file before generating it again, to
    # avoid duplicated keys.
    FileUtils.rm(FROZEN_STRINGS_PATH)

    # Other apps call `ios_generate_strings_file_from_code` as the first step
    # of this process, but Pocket Casts iOS uses the convention of defining all
    # localized strings in the `en.lproj/Localizable.strings` file and then use
    # SwiftGen to generate reference to them for the code. With this approach,
    # there are no `NSLocalizedStrings` in the codebase and that action would
    # be useless.

    # Merge the various `.strings` files into a single "frozen" `.strings`
    # so that we can update all keys into a single GlotPress project.
    #
    # Note: We will re-extract the translations back during
    # `download_localized_strings_from_glotpress` (via a call to
    # `ios_extract_keys_from_strings_files`)
    ios_merge_strings_files(
      paths_to_merge: MANUALLY_MAINTAINED_STRINGS_FILES,
      destination: FROZEN_STRINGS_PATH
    )

    next if skip_commit

    git_commit(
      path: FROZEN_STRINGS_PATH,
      message: 'Freeze strings for localization',
      allow_nothing_to_commit: true
    )
  end

  # Run `aspell` tool on `en.lproj/*.strings` files to spell-check their values
  #
  # Prints the found typos to stdout (using ANSI colors in the terminal output to highlight them in context).
  #
  # @param fail_on_typos [Boolean] If true, will exit with `UI.user_error!` if typos are found, thus interrupting fastlane.
  #                                If false (the default), will just return the number of typos without raising an exception
  # @return the number of typos found (0 if none)
  # @raise if `aspell` or `plutil` tools are not installed, or if typos are found and `fail_on_typos` was `true`
  #
  lane :spellcheck_strings do |fail_on_typos: false|
    spellcheck_locale = 'en-US'

    _, status = Open3.capture2e('which', 'aspell')
    UI.user_error!('Please install the `aspell` utility using `brew install aspell` first') unless status.success?

    typos_count = 0
    MANUALLY_MAINTAINED_STRINGS_FILES.each_key do |strings_file|
      # Parse strings file as dictionary
      out, err, status = Open3.capture3('plutil', '-convert', 'json', '-o', '-', strings_file)
      UI.shell_error!("Encountered an error while trying to convert #{strings_file} to JSON: #{err}") unless status.success?

      dict = JSON.parse(out)
      dict.each do |key, text|
        # Spell-check each entry
        text.gsub!(/\${[a-zA-Z]+}/, 'this') # To avoid `${variable}` placeholders in Intents.strings to be seen as typos
        out, err, status = Open3.capture3('aspell', 'list', "--lang=#{spellcheck_locale}", stdin_data: text)
        UI.shell_error!("Error spellchecking key `#{key}`: #{err}") unless err.empty? && status.success?

        typos = out.split("\n") - SPELLCHECK_KNOWN_WORDS
        next if typos.empty?

        highlighted = typos.reduce(text.cyan) { |str, typo| str.gsub(typo, typo.red.underline + Colored.color(:cyan)) }
        UI.important("Typo found in key `#{key}` (`#{File.basename(strings_file)}`)")
        UI.message("« #{highlighted} »")

        typos_count += typos.count
      end
    end

    if typos_count.zero?
      UI.success('No typo found!')
    else
      UI.send(fail_on_typos ? :user_error! : :error, "#{typos_count} typo(s) found")
    end
    typos_count
  end

  # Downloads localized strings and App Store Connect metadata from GlotPress
  #
  lane :download_localized_strings_and_metadata_from_glotpress do
    download_localized_strings_from_glotpress
    download_localized_app_store_metadata_from_glotpress
  end

  # Lint the `.strings` files
  #
  lane :lint_localizations do
    ios_lint_localizations(
      input_dir: File.join(PROJECT_ROOT_FOLDER, 'podcasts'),
      allow_retry: true,
      check_duplicate_keys: false
    )
  end

  # Updates the `AppStoreStrings.po` file using the content from the `release_notes.txt` file and other `.txt` sources
  #
  lane :update_app_store_strings do
    source_metadata_folder = File.join(APP_STORE_METADATA_FOLDER, 'default')
    version = get_version_number(xcodeproj: XCODE_PROJECT_PATH, target: 'podcasts')

    files = {
      whats_new: File.join(source_metadata_folder, 'release_notes.txt'),
      app_store_subtitle: File.join(source_metadata_folder, 'subtitle.txt'),
      app_store_desc: File.join(source_metadata_folder, 'description.txt'),
      app_store_keywords: File.join(source_metadata_folder, 'keywords.txt')
    }

    ios_update_metadata_source(
      po_file_path: File.join(PROJECT_ROOT_FOLDER, 'fastlane', 'AppStoreStrings.po'),
      source_files: files,
      release_version: version
    )
  end

  # Downloads localized `.strings` from GlotPress
  #
  lane :download_localized_strings_from_glotpress do
    # Use the same name as the frozen strings source to make it explicit these
    # are not to be copied as-is in the `*.lproj/Localizable.strings`
    table_basename = basename_without_extension(path: FROZEN_STRINGS_PATH)

    # Notice that we don't need to track the files we'll download here in Git,
    # because the content they carry will be read and ported into the
    # appropriate individual localization files next.
    download_dir = File.join(FASTLANE_FOLDER, 'app-localization-downloads')
    ios_download_strings_files_from_glotpress(
      project_url: GLOTPRESS_APP_STRINGS_PROJECT_URL,
      locales: GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES,
      download_dir: download_dir,
      table_basename: table_basename
    )

    # Redispatch the appropriate subset of translations back to the individual
    # `.strings` files that we merged via `ios_merge_strings_files` during `code_freeze`.
    modified_files = ios_extract_keys_from_strings_files(
      source_parent_dir: download_dir,
      source_tablename: table_basename,
      target_original_files: MANUALLY_MAINTAINED_STRINGS_FILES
    )
    git_commit(
      path: modified_files,
      message: 'Update localization files with up-to-date values from GlotPress',
      allow_nothing_to_commit: true
    )
  end

  # Downloads localized metadata for App Store Connect from GlotPress
  #
  lane :download_localized_app_store_metadata_from_glotpress do
    # FIXME: Replace this with a call to the future replacement of
    # `gp_downloadmetadata` once it's implemented in the release-toolkit (see
    # paaHJt-31O-p2).
    target_files = {
      "v#{release_version_current}-whats-new": {
        desc: 'release_notes.txt',
        max_size: 4000
      },
      app_store_subtitle: { desc: 'subtitle.txt', max_size: 30 },
      app_store_desc: { desc: 'description.txt', max_size: 4000 },
      app_store_keywords: { desc: 'keywords.txt', max_size: 100 }
    }

    gp_downloadmetadata(
      project_url: GLOTPRESS_APP_STORE_METADATA_PROJECT_URL,
      target_files: target_files,
      locales: GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES,
      download_path: APP_STORE_METADATA_FOLDER
    )
    files_to_commit = [File.join(APP_STORE_METADATA_FOLDER, '**', '*.txt')]

    # Ensure that none of the `.txt` files in `en-US` would accidentally
    # override our originals in `default`
    target_files.values.map { |h| h[:desc] }.each do |file|
      en_file_path = File.join(APP_STORE_METADATA_FOLDER, 'en-US', file)
      next unless File.exist?(en_file_path)

      UI.user_error! <<~ERROR
        File `#{en_file_path}` would override the same one in `#{APP_STORE_METADATA_FOLDER}/default`, but `default/` is the source of truth.
        Delete the `#{en_file_path}` file, ensure the `default/` one has the expected original copy, and try again.
      ERROR
    end

    # Ensure even empty locale folders have an empty `.gitkeep` file (in case
    # we don't have any translation at all ready for some locales)
    GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES.each_value do |locale|
      gitkeep = File.join(APP_STORE_METADATA_FOLDER, locale, '.gitkeep')
      next if File.exist?(gitkeep)

      FileUtils.mkdir_p(File.dirname(gitkeep))
      FileUtils.touch(gitkeep)
      files_to_commit.append(gitkeep)
    end

    # Commit
    git_add(path: files_to_commit, shell_escape: false)
    git_commit(
      path: files_to_commit,
      message: 'Update App Store metadata translations',
      allow_nothing_to_commit: true
    )
  end

  # Checks the translation progress (%) of all Mag16 for all the projects (app
  # strings and metadata) in GlotPress.
  #
  # @param interactive [Boolean] If true, will pause and ask confirmation to continue
  #        if it found any locale translated below the threshold (default: false)
  #
  lane :check_all_translations_progress do |interactive: false|
    abort_on_violations = false
    skip_confirm = interactive == false

    UI.header('Checking app strings translation status...')
    check_translation_progress(
      glotpress_url: GLOTPRESS_APP_STRINGS_PROJECT_URL,
      language_codes: GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES.keys,
      abort_on_violations: abort_on_violations,
      skip_confirm: skip_confirm
    )

    UI.header('Checking release notes strings translation status...')
    check_translation_progress(
      glotpress_url: GLOTPRESS_APP_STORE_METADATA_PROJECT_URL,
      language_codes: GLOTPRESS_TO_ASC_METADATA_LOCALE_CODES.keys,
      abort_on_violations: abort_on_violations,
      skip_confirm: skip_confirm
    )
  end

  # Upload the localized metadata (from `fastlane/metadata/`) to App Store Connect
  #
  # @param with_screenshots [Boolean] If true, will also upload the latest screenshot files to ASC (default: false)
  #
  lane :update_metadata_on_app_store_connect do |with_screenshots: false|
    # Skip screenshots by default. The naming is "with" to make it clear that
    # callers need to opt-in to adding screenshots. The naming of the deliver
    # (upload_to_app_store) parameter, on the other hand, uses the skip verb.
    skip_screenshots = with_screenshots == false

    upload_to_app_store(
      app_identifier: APP_BUNDLE_IDENTIFIER_APP_STORE,
      app_version: release_version_current,
      skip_binary_upload: true,
      screenshots_path: File.join(FASTLANE_FOLDER, 'screenshots'),
      skip_screenshots: skip_screenshots,
      overwrite_screenshots: true, # won't have effect if `skip_screenshots` is true
      phased_release: true,
      precheck_include_in_app_purchases: false,
      api_key: app_store_connect_api_key
    )
  end

  # Generates a HTML containing the libraries acknowledgments.
  #
  lane :acknowledgments do
    require 'commonmarker'

    acknowledgements = 'Acknowledgments'
    markdown = File.read("#{PROJECT_ROOT_FOLDER}/podcasts/acknowledgements.md")
    rendered_html = CommonMarker.render_html(markdown, :DEFAULT)
    styled_html = "<head>
                       <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
                       <style>
                         body {
                           font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
                           Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
                           font-size: 16px;
                           color: #1a1a1a;
                           margin: 20px;
                         }
                        @media (prefers-color-scheme: dark) {
                         body {
                          background: #1a1a1a;
                          color: white;
                         }
                        }
                         pre {
                          white-space: pre-wrap;
                         }
                       </style>
                       <title>
                         #{acknowledgements}
                       </title>
                     </head>
                     <body>
                       #{rendered_html}
                     </body>"

    ## Remove the <h1>, since we've promoted it to <title>
    styled_html = styled_html.sub('<h1>Acknowledgements</h1>', '')

    ## The glog library's license contains a URL that does not wrap in the web view,
    ## leading to a large right-hand whitespace gutter.  Work around this by explicitly
    ## inserting a <br> in the HTML.  Use gsub juuust in case another one sneaks in later.
    styled_html = styled_html.gsub('p?hl=en#dR3YEbitojA/COPYING', 'p?hl=en#dR3YEbitojA/COPYING<br>')

    File.write("#{PROJECT_ROOT_FOLDER}/podcasts/acknowledgements.html", styled_html)
  end

  #####################################################################################
  # Screenshots Automation
  #####################################################################################

  # Generates localized screenshots for the iPhone, and iPad.
  #
  # Tests run in the simulator so be sure to make any necessary Podfile changes such as
  # converting to use google-cast-sdk-no-bluetooth-mock
  #
  lane :screenshots do
    iphone_devices = ['iPhone 12']
    ipad_devices = ['iPad (9th generation)']

    # Build once to speed up the other runs
    scan(
      project: 'podcasts.xcodeproj',
      scheme: 'Screenshot Automation',
      build_for_testing: true,
      clean: true,
      devices: iphone_devices + ipad_devices,
      reset_simulator: true
    )

    # iPhone Light Intertace Screens
    snapshot(
      derived_data_path: lane_context[SharedValues::SCAN_DERIVED_DATA_PATH],
      devices: iphone_devices,
      dark_mode: false,
      erase_simulator: true,
      testplan: 'ScreenshotAutomation_iPhone_Light_Interface'
    )

    # iPhone Dark Intertace Screens
    snapshot(
      derived_data_path: lane_context[SharedValues::SCAN_DERIVED_DATA_PATH],
      devices: iphone_devices,
      dark_mode: true,
      testplan: 'ScreenshotAutomation_iPhone_Dark_Interface'
    )

    # iPad Light Intertace Screens
    snapshot(
      derived_data_path: lane_context[SharedValues::SCAN_DERIVED_DATA_PATH],
      devices: ipad_devices,
      dark_mode: false,
      erase_simulator: true,
      testplan: 'ScreenshotAutomation_iPad_Light_Interface'
    )

    # iPhone Dark Intertace Screens
    snapshot(
      derived_data_path: lane_context[SharedValues::SCAN_DERIVED_DATA_PATH],
      devices: ipad_devices,
      dark_mode: true,
      testplan: 'ScreenshotAutomation_iPad_Dark_Interface'
    )
  end

  # Generates localized screenshots for the Apple Watch.
  #
  # Tests run in the simulator so be sure to make any necessary Podfile changes such as
  # converting to use google-cast-sdk-no-bluetooth-mock
  #
  # Setup:
  #  - Log into an account with Plus. Run the test iPhone_GenerateScreenshots.test_watchSetup
  #    on a device that is connected to the watch mentioned in the test.
  #  - Ensure the data syncs to the simulated watch. Mocking out the ApplicationContext from
  #    the device can help ensure a consistent response.
  #
  lane :watch_screenshots do
    watch_devices = ['Apple Watch Series 7 - 45mm']

    snapshot(
      scheme: 'Screenshot Automation Watch',
      devices: watch_devices,
      test_without_building: false
    )
  end

  #####################################################################################
  # Helper Functions
  #####################################################################################

  # Name of the `release/` branch for the current version
  #
  def release_branch_name
    "release/#{release_version_current}"
  end

  # Kicks off a Buildkite build using the `release-builds.yml` pipeline
  #
  # @param branch [String] The git branch on which to trigger the CI build on
  # @param beta [Boolean] If true, build a beta; if false, build a final release
  # @return [String] The URL of the build that has been triggered
  #
  def trigger_buildkite_release_build(branch:, beta:)
    pipeline_args = {
      pipeline_file: 'release-builds.yml',
      environment: {
        RELEASE_VERSION: release_version_current,
        BETA_RELEASE: beta
      }
    }
    if is_ci?
      buildkite_pipeline_upload(**pipeline_args)
    else
      require_env_vars!('BUILDKITE_TOKEN')
      buildkite_trigger_build(
        buildkite_organization: 'automattic',
        buildkite_pipeline: 'pocket-casts-ios',
        branch: branch,
        **pipeline_args
      )
    end
  end

  # Update the milestone of still-open PRs
  #
  # - Update the milestone of all still-open PRs that were assigned the milestone of the version being code-frozen,
  #   to move them to the next milestone
  # - Leaving a PR comment on each of the PRs for which we updated the miletsone
  # - Add the ❄️ frozen marker to the milestone being frozen
  #
  def update_milestone
    require_env_vars!('GITHUB_TOKEN')

    new_version = release_version_current
    next_version = release_version_next
    begin
      # Move PRs to next milestone
      moved_prs = update_assigned_milestone(
        repository: GITHUB_REPO,
        from_milestone: new_version,
        to_milestone: next_version,
        comment: "Version `#{new_version}` has now entered code-freeze, so the milestone of this PR has been updated to `#{next_version}`."
      )

      # Add ❄️ marker to milestone title to indicate we entered code-freeze
      set_milestone_frozen_marker(
        repository: GITHUB_REPO,
        milestone: new_version
      )
    rescue StandardError => e
      moved_prs = []

      error_message = <<-MESSAGE
        Error freezing milestone `#{new_version}`: #{e.message}

        - If this is not the first time you are running the release task (e.g. retrying because it failed on first attempt), the milestone might have already been closed and this error is expected.
        - Otherwise if this is the first you are running the release task for this version, please investigate the error.
      MESSAGE

      UI.error(error_message)
      buildkite_annotate(style: 'warning', context: 'error-with-milestone', message: error_message) if is_ci
    end

    UI.message("Moved the following PRs to milestone #{next_version}: #{moved_prs.join(', ')}")

    # Annotate the build with the moved PRs
    moved_prs_info = if moved_prs.empty?
                       "👍 No open PR were targeting `#{new_version}` at the time of code-freeze"
                     else
                       "#{moved_prs.count} PRs targeting `#{new_version}` were still open and thus moved to `#{next_version}`:\n" \
                         + moved_prs.map { |pr_num| "[##{pr_num}](https://github.com/#{GITHUB_REPO}/pull/#{pr_num})" }.join(', ')
                     end
    buildkite_annotate(style: moved_prs.empty? ? 'success' : 'warning', context: 'start-code-freeze', message: moved_prs_info) if is_ci
  end

  # Private helper to create backmerge Pull Requests after new betas or final build
  #
  def create_backmerge_pr
    create_release_backmerge_pull_request(
      repository: GITHUB_REPO,
      source_branch: release_branch_name,
      default_branch: DEFAULT_BRANCH,
      labels: ['Releases'],
      milestone_title: release_version_next
    )
  end

  # Constructs the slack message body to use for a given version
  #
  # @param version [String] The version number this Slack message will be about
  # @param build_number [String] The build number this Slack message will be about
  # @param is_beta [Boolean] Set to true if the Slack message is an announcement for a beta build and not a final release/hotfix
  # @return [String] The slack message body to use, typically in a call to the `slack()` fastlane action
  #
  def slack_message(version:, build_number:, is_beta:)
    build_number_split = build_number.split('.')

    message_root = lambda { |tag, display_name|
      ":announcement: <#{GITHUB_URL}/releases/tag/#{tag}|*#{display_name}*>"
    }

    if is_beta
      if (build_number_split[3] || '0') == '0'
        "#{message_root.call(build_number, version)} code freeze is completed."
      else
        "#{message_root.call(build_number, build_number)} beta has been submitted to Apple."
      end
    elsif (build_number_split[2] || '0').to_i.positive?
      "#{message_root.call(version, version)} hotfix has been uploaded to Apple."
    else
      "#{message_root.call(version, version)} final build has been uploaded to Apple."
    end
  end

  def basename_without_extension(path:)
    File.basename(path, '.*')
  end

  def override_default_release_branch(version)
    success = Fastlane::Helper::GitHelper.checkout_and_pull(release: version)
    UI.user_error!("Release branch for version #{version} doesn't exist. Abort.") unless success

    UI.message "Checked out branch `#{git_branch}` as requested by user.\n"
  end

  def pull_request_number
    # Buildkite sets this env var to the PR number if on a PR, but to 'false' (and not nil) if not on a PR
    pr_num = ENV.fetch('BUILDKITE_PULL_REQUEST', 'false')
    pr_num == 'false' ? nil : Integer(pr_num)
  end

  # Create a commit with the typical message to use for a version bump
  #
  def commit_version_bump
    git_commit(
      path: VERSION_XCCONFIG_PATH,
      message: 'Bump version number',
      allow_nothing_to_commit: false
    )
  end

  #####################################################################################
  # Versioning Methods
  #####################################################################################

  # Returns the release version of the app in the format `1.2` or `1.2.3` if it is a hotfix
  #
  def release_version_current
    # Read the current release version from the .xcconfig file and parse it into an AppVersion object
    current_version = VERSION_FORMATTER.parse(VERSION_FILE.read_release_version)
    # Return the formatted release version
    VERSION_FORMATTER.release_version(current_version)
  end

  #  Returns the next release version of the app in the format `1.2` or `1.2.3` if it is a hotfix
  #
  def release_version_next
    # Read the current release version from the .xcconfig file and parse it into an AppVersion object
    current_version = VERSION_FORMATTER.parse(VERSION_FILE.read_release_version)
    # Calculate the next release version
    next_calculated_release_version = VERSION_CALCULATOR.next_release_version(version: current_version)
    # Return the formatted release version
    VERSION_FORMATTER.release_version(next_calculated_release_version)
  end

  # Returns the current build code of the app
  #
  def build_code_current
    # Read the current build code from the .xcconfig file and parse it into an AppVersion object
    # The AppVersion is used because Pocket Casts iOS uses the four part (1.2.3.4) build code format, so the version
    # calculator can be used to calculate the next four-part version
    version = VERSION_FORMATTER.parse(VERSION_FILE.read_build_code(attribute_name: BUILD_CODE_KEY))
    # Return the formatted build code
    BUILD_CODE_FORMATTER.build_code(version: version)
  end

  # Returns the initial build code for a code freeze (e.g., "1.2.3.0" for release version "1.2.3")
  # Takes the release version and formats it as a four-part build code with the build number set to 0
  #
  def build_code_code_freeze(version_short: nil)
    # Use provided version or read the current release version from the .xcconfig file
    version_short ||= VERSION_FILE.read_release_version
    # Parse the release version string (e.g., "1.2.3") into an AppVersion object
    release_version_current = VERSION_FORMATTER.parse(version_short)
    # Format as four-part build code (e.g., "1.2.3.0")
    BUILD_CODE_FORMATTER.build_code(version: release_version_current)
  end

  # Returns the build code of the app for a hotfix. It is the hotfix version name plus sets the build number to 0
  #
  # @param release_version [String] The `x.y.z` hotfix version we want to get the build code for
  #
  def build_code_hotfix(release_version:)
    version = VERSION_FORMATTER.parse(release_version)
    # Return the formatted build code
    BUILD_CODE_FORMATTER.build_code(version: version)
  end

  # Return true if the version of the current branch is a hotfix version
  #
  def current_version_hotfix?
    current_version = VERSION_FORMATTER.parse(VERSION_FILE.read_release_version)
    VERSION_CALCULATOR.release_is_hotfix?(version: current_version)
  end

  # Returns the next build code of the app
  #
  def build_code_next
    # Read the current build code from the .xcconfig file and parse it into an AppVersion object
    # The AppVersion is used because Pocket Casts iOS uses the four part (1.2.3.4) build code format, so the version
    # calculator can be used to calculate the next four-part version
    build_code_current = VERSION_FORMATTER.parse(VERSION_FILE.read_build_code(attribute_name: BUILD_CODE_KEY))
    # Calculate the next build code
    build_code_next = VERSION_CALCULATOR.next_build_number(version: build_code_current)
    # Return the formatted build code
    BUILD_CODE_FORMATTER.build_code(version: build_code_next)
  end

  def remove_app_clip_dependency!
    project_path = XCODE_PROJECT_PATH
    project = Xcodeproj::Project.open(project_path)

    native_targets = project.targets.select { |t| t.is_a?(Xcodeproj::Project::Object::PBXNativeTarget) }
    app_targets = native_targets.select { |t| t.product_type == 'com.apple.product-type.application' }
    app_clip_targets = native_targets.select { |t| t.product_type == 'com.apple.product-type.application.on-demand-install-capable' }

    if app_clip_targets.empty?
      UI.user_error!("No on-demand-install-capable targets found in #{project_path}. Has something changed in the project structure? Aborting App Clips removal.")
    end

    app_targets.each do |app|
      # Delete any dependency of the app target that is an app_clip target
      app.dependencies.delete_if { |dep| app_clip_targets.include?(dep.target) }
      # Delete the "Embed App Clips" build phase
      app.build_phases.delete_if { |b| b.display_name == 'Embed App Clips' }
    end

    UI.message('Saving the project file....')
    project.save

    UI.success('Dependency successfuly removed.')
  end

  def ensure_branch_does_not_exist!(branch_name)
    return unless Fastlane::Helper::GitHelper.branch_exists_on_remote?(branch_name: branch_name)

    error_message = "The branch `#{branch_name}` already exists. Please check first if there is an existing Pull Request that needs to be merged or closed first, " \
                    'or delete the branch to then run again the release task.'

    buildkite_annotate(style: 'error', context: 'error-checking-branch', message: error_message) if is_ci

    UI.user_error!(error_message)
  end
end
